import { fromObject, generateMapFileComment } from 'convert-source-map'
import { readFile } from 'fs/promises'
import { isArray, isBuffer, isObject, isString, noop, once } from 'lodash-es'
import MagicString from 'magic-string'
import path, { posix as posixPath } from 'path'
import type { InternalModuleFormat } from 'rollup'
import { promisify } from 'util'
import VirtualModulesPlugin from 'webpack-virtual-modules'
import {
  commonInputFS,
  placeholderGlobalMatchRegexp,
  placeholderPrefix,
  placeholderRegexp,
  placeholderSuffix
} from '../common'
import { getCompluginDirnameExp } from '../helpers'
import { ILogger } from '../logger'
import { nodeResolve } from '../resolve'
import {
  AcornCodeKey,
  AcornNodePri,
  Awaitable,
  CompluginContextMeta,
  CompluginContextMetaData,
  CompluginOptions,
  LoadResult,
  OutputFile,
  ResolveIdResult,
  SourceMap,
  SourceMapInput
} from '../types'
import {
  addSourceComment,
  AsyncValue,
  asyncValue,
  CWD,
  getExtname,
  isAst,
  isEmitAssetArgs,
  iterableMap,
  joinIterables,
  makeLegalPath,
  makeRelativeLegalPosixPath,
  mergeSourceMap,
  normalizeSourceMap,
  stringifyModuleId,
  toBuffer
} from '../utils'
import {
  getAssetFileName,
  Load_INFO_LOADER,
  resolveWebpackExternals,
  setWebpackAsset,
  toWebpackSource,
  WEBPACK_PUBLIC_PATH
} from './utils'

export interface CompluginWebpackPluginContext extends Pick<CompluginOptions, 'transform'> {
  pluginName: string
  compluginName: string
  transformLogger: ILogger
}
export type LoadProxyFn = (
  id: string,
  resource: string,
  compilation: import('webpack').Compilation
) => Awaitable<LoadResult>

type CompluginWebpackPluginContextMap = Record<string, CompluginWebpackPluginContext>

declare module 'webpack' {
  interface Compiler {
    // @ts-ignore
    readonly $devServer?: import('webpack-dev-server')
    readonly $compluginInjected: {
      readonly meta: Omit<CompluginContextMeta & { framework: 'webpack' }, 'plugin' | 'logger'>
      readonly plugins: CompluginWebpackPluginContextMap
      readonly usedChunkNames: Set<string>
      readonly staticFiles: Map<string, string>
      readonly vfs: VirtualModulesPlugin
      readonly loadMap: Map<string, [code: string, map?: SourceMapInput, ast?: AcornNodePri]>
      readonly thisCompilationPromise: AsyncValue<import('webpack').Compilation>
      readonly data: CompluginContextMetaData
      readonly emittedAssets: Map<
        string,
        Awaitable<string | Uint8Array | { code: string | Uint8Array; map?: SourceMap }>
      >
      onBuildStarts(fn: (compilation: import('webpack').Compilation) => Awaitable<void>, enforce?: 'pre' | 'post'): void
      onBuildEnds(fn: (compilation: import('webpack').Compilation) => Awaitable<void>, enforce?: 'pre' | 'post'): void
      onResolveIdAndLoads(
        resolveId: (
          compilation: import('webpack').Compilation,
          importee: string,
          importer?: string
        ) => Awaitable<ResolveIdResult>,
        load?: LoadProxyFn,
        enforce?: 'pre' | 'post'
      ): void
      onGenerateBundle(
        fn: (bundle: Record<string, OutputFile>, rawBundle: any) => Awaitable<void>,
        enforce?: 'pre' | 'post'
      ): void
      resolveId(importee: string, importer?: string): Promise<{ id: string; external: boolean } | undefined>
      requestToVirtualResource(request: string): string
      virtualResourceToRequest(virtualResource?: string): string | undefined
      getRequestVirtualResource(request?: string): string | undefined
      addWatchFile(resource: string, watchedFile: string): void
      addRule(rule: import('webpack').RuleSetRule): void
      rebuild(resource: string, compilation: import('webpack').Compilation): void
      isEntry(module: import('webpack').NormalModule): boolean
    }
  }
}

const sourceAndMap: import('webpack').sources.Source['sourceAndMap'] = function sourceAndMap(
  this: import('webpack').sources.Source,
  options
) {
  return {
    source: this.source(),
    map: this.map(options)
  }
}

const promisifyCache = new WeakMap<(...args: any[]) => any, (...args: any[]) => any>()
const getPromisify: typeof promisify = ((fn: (...args: any[]) => any) => {
  if (typeof fn !== 'function') return
  let result = promisifyCache.get(fn)
  if (!result) {
    promisifyCache.set(fn, (result = promisify(fn)))
  }
  return result
}) as any

export const initCompluginWebpackInjected = (compiler: import('webpack').Compiler) => {
  let injected = compiler.$compluginInjected
  if (injected) return injected

  const thisCompilationPromise = asyncValue<import('webpack').Compilation>()
  const resolverPromise = asyncValue<import('webpack').Resolver>()

  const externalIds = new Set<string>()
  const vfsToRequestMap: Map<string, string> = new Map()
  const requestToVfsMap: Map<string, string> = new Map()
  const virtualPrefix = path.resolve(CWD, '__virtual__')
  const requestToVirtualResource = (request: string) => {
    let resource = requestToVfsMap.get(request)
    if (!resource) {
      let virtualResource: string
      if (path.isAbsolute(request)) {
        virtualResource = makeLegalPath(request)
      } else {
        virtualResource = path.join(virtualPrefix, path.basename(makeRelativeLegalPosixPath(request)))
      }

      if (virtualResource === request) {
        return request
      }

      let ext = getExtname(virtualResource)
      let base = virtualResource.slice(0, virtualResource.length - ext.length)
      let i = 0

      ext = ext || '.js'
      virtualResource = base + ext
      while (vfsToRequestMap.has(virtualResource)) {
        virtualResource = `${base}-${++i}${ext}`
      }

      resource = virtualResource

      if (request !== resource) {
        requestToVfsMap.set(request, resource)
        vfsToRequestMap.set(resource, request)
      }
    }

    return resource
  }

  const virtualResourceToRequest = (virtualResource?: string) => {
    return (vfsToRequestMap.get(virtualResource!) ?? virtualResource) || undefined
  }

  const getRequestVirtualResource = (request?: string) => {
    return (requestToVfsMap.get(request!) ?? request) || ''
  }

  const getExternalModuleType = (compilation: import('webpack').Compilation) => {
    return compilation.runtimeTemplate.supportsEcmaScriptModuleSyntax?.() ? 'module' : 'commonjs2'
  }

  let vfs: VirtualModulesPlugin | undefined
  let hasCopyModule = false
  let copyModuleStart = 0xffffn

  const uniqueCopyModuleRef = () => (++copyModuleStart).toString(36)
  const preBuildStarts: Array<(compilation: import('webpack').Compilation) => Awaitable<void>> = []
  const postBuildStarts: Array<(compilation: import('webpack').Compilation) => Awaitable<void>> = []
  const buildStarts: Array<(compilation: import('webpack').Compilation) => Awaitable<void>> = []

  const preBuildEnds: Array<(compilation: import('webpack').Compilation) => Awaitable<void>> = []
  const postBuildEnds: Array<(compilation: import('webpack').Compilation) => Awaitable<void>> = []
  const buildEnds: Array<(compilation: import('webpack').Compilation) => Awaitable<void>> = []

  const preResolveIds: Array<
    [
      resolveId: (
        compilation: import('webpack').Compilation,
        importee: string,
        importer?: string
      ) => Awaitable<ResolveIdResult>,
      load?: LoadProxyFn
    ]
  > = []
  const postResolveIds: Array<
    [
      resolveId: (
        compilation: import('webpack').Compilation,
        importee: string,
        importer?: string
      ) => Awaitable<ResolveIdResult>,
      load?: LoadProxyFn
    ]
  > = []
  const resolveIds: Array<
    [
      resolveId: (
        compilation: import('webpack').Compilation,
        importee: string,
        importer?: string
      ) => Awaitable<ResolveIdResult>,
      load?: LoadProxyFn
    ]
  > = []

  const preGenerateBundles: Array<(bundle: Record<string, OutputFile>, rawBundle: any) => Awaitable<void>> = []
  const postGenerateBundles: Array<(bundle: Record<string, OutputFile>, rawBundle: any) => Awaitable<void>> = []
  const generateBundles: Array<(bundle: Record<string, OutputFile>, rawBundle: any) => Awaitable<void>> = []

  const copyModuleRefsMap = new Map<string, string>()
  const watchFilesMap = new Map<string, Set<string>>()
  const resolveRecordsMap = new Map<string, Map<string, string>>()
  const chunkAssetNameMap = new Map<string, import('webpack').Chunk>()
  const loadMap = new Map<string, [code: string, map?: SourceMapInput, ast?: AcornNodePri]>()

  const ConstDependency = compiler.webpack.dependencies.ConstDependency
  const ModuleDependency = compiler.webpack.dependencies.ModuleDependency
  const WebpackModule = compiler.webpack.Module
  const RuntimeModule = compiler.webpack.RuntimeModule
  const ExternalModule = compiler.webpack.ExternalModule

  compiler.options.module.rules.push({
    include: (resource: string) => {
      if (!resource) return false

      return loadMap.has(resource)
    },
    enforce: 'pre',
    loader: Load_INFO_LOADER
  })

  const rulesLength = compiler.options.module.rules.length

  ;(compiler as any).$compluginInjected = injected = {
    plugins: Object.create(null),
    data: Object.create(null),
    usedChunkNames: new Set(),
    staticFiles: new Map(),
    emittedAssets: new Map(),
    loadMap,
    thisCompilationPromise,
    requestToVirtualResource,
    virtualResourceToRequest,
    getRequestVirtualResource,
    onBuildStarts(fn, enforce) {
      const array = enforce === 'pre' ? preBuildStarts : enforce === 'post' ? postBuildStarts : buildStarts
      array.push(fn)
    },
    onBuildEnds(fn, enforce) {
      const array = enforce === 'pre' ? preBuildEnds : enforce === 'post' ? postBuildEnds : buildEnds
      array.push(fn)
    },
    onResolveIdAndLoads(resolveId, load, enforce) {
      injected.vfs
      const array = enforce === 'pre' ? preResolveIds : enforce === 'post' ? postResolveIds : resolveIds
      array.push([resolveId, load])
    },
    onGenerateBundle(fn, enforce) {
      const array = enforce === 'pre' ? preGenerateBundles : enforce === 'post' ? postGenerateBundles : generateBundles
      array.push(fn)
    },
    async resolveId(importee, importer) {
      const resolveId = resolveRecordsMap.get(importer || '')?.get(importee)
      if (resolveId != null) {
        return {
          id: resolveId,
          external: externalIds.has(resolveId)
        }
      }

      const compilation = await thisCompilationPromise

      for (const [resolveId] of joinIterables(preResolveIds, resolveIds, postResolveIds)) {
        const result = await resolveId(compilation, importee, importer)
        if (isString(result)) {
          return {
            id: String(result),
            external: false
          }
        } else if (isObject(result)) {
          if (!isEmitAssetArgs(result)) {
            const { id, external } = result
            return { id, external: Boolean(external) }
          }
        }
      }

      return new Promise(async (resolve, reject) => {
        try {
          const resolver = await resolverPromise

          return resolver.resolve(
            {
              issuer: injected.requestToVirtualResource(importer!),
              issuerLayer: null
            },
            compiler.context,
            importee,
            {},
            (err, resource) => {
              if (err) {
                return reject(err)
              }
              if (resource === false) {
                return resolve(undefined)
              }
              if (resource == null && path.isAbsolute(importee)) {
                return nodeResolve(importee, importer)
              }
              resource = injected.virtualResourceToRequest(resource)
              if (resource == null) {
                return resolve(undefined)
              }

              Promise.resolve()
                .then(() =>
                  resolveWebpackExternals(
                    { context: compiler.context, request: resource as string },
                    compiler.options.externals
                  )
                )
                .then(
                  external =>
                    resolve({
                      id: resource as string,
                      external: external ?? false
                    }),
                  err => reject(err)
                )
            }
          )
        } catch (error) {
          reject(error)
        }
      })
    },
    addWatchFile(resource, watchedFile) {
      watchedFile = path.resolve(CWD, watchedFile)

      let set = watchFilesMap.get(resource)
      if (!set) {
        watchFilesMap.set(resource, (set = new Set()))
      }

      set.add(watchedFile)
    },
    addRule(rule) {
      const rules = compiler.options.module.rules
      if (rules.length < rulesLength) {
        rules.push(rule)
      } else {
        rules.splice(rulesLength, 0, rule)
      }
    },
    rebuild(resource, compilation) {
      if (!injected.meta.watchMode) return
      if (resource == null) return

      Promise.resolve().then(() => {
        const module = compilation.findModule(resource)
        if (module == null) return
        compilation.rebuildModule(module, noop)
      })
    },
    get vfs() {
      vfs = vfs ?? (compiler.options.plugins.find(i => i instanceof VirtualModulesPlugin) as VirtualModulesPlugin)
      if (!vfs) {
        vfs = new VirtualModulesPlugin()
        vfs.apply(compiler)
      }

      return vfs
    },
    meta: {
      framework: 'webpack',
      version: compiler.webpack.version,
      watchMode: compiler.watchMode,
      compiler,
      data: Object.create(null),
      isProd: (compiler.options.mode ?? process.env.NODE_ENV) === 'production',
      root: compiler.context,
      inputFS: {
        async readFile(filename) {
          return (await getPromisify(compiler.inputFileSystem.readFile).call(compiler.inputFileSystem, filename)) ?? ''
        },
        async readlink(filename) {
          return (
            (await getPromisify(compiler.inputFileSystem.readlink).call(compiler.inputFileSystem, filename)) ?? ''
          ).toString()
        },
        async readdir(dirname) {
          const result = await getPromisify(compiler.inputFileSystem.readdir).call(compiler.inputFileSystem, dirname)
          return (
            await Promise.all(
              result?.map(async file => {
                if (typeof file === 'string' || isBuffer(file)) {
                  const stat = await injected.meta.inputFS.stat(path.join(dirname, file.toString()))
                  if (stat) {
                    // @ts-ignore
                    stat.name = file

                    return stat
                  }
                } else {
                  return file
                }
              }) ?? []
            )
          ).filter(Boolean) as any
        },
        async stat(filename) {
          try {
            return (await getPromisify(compiler.inputFileSystem.stat).call(compiler.inputFileSystem, filename)) as any
          } catch {}
        },
        async lstat(filename) {
          try {
            return (await getPromisify(compiler.inputFileSystem.lstat ?? compiler.inputFileSystem.stat).call(
              compiler.inputFileSystem,
              filename
            )) as any
          } catch {}
        },
        async realpath(filename) {
          try {
            return (
              await (getPromisify(compiler.inputFileSystem.realpath!) ?? commonInputFS.realpath).call(
                compiler.inputFileSystem,
                filename
              )
            )?.toString()
          } catch {}
        }
      },
      get devServer() {
        return compiler.$devServer
      }
    },
    isEntry: module => {
      let isEntry: import('webpack').Compiler['$compluginInjected']['isEntry']

      const entries = thisCompilationPromise.value?.entries
      if (entries instanceof Map) {
        isEntry = module => {
          return module.resourceResolveData?.context?.issuer === ''
        }
      } else if (isArray(entries)) {
        isEntry = module => {
          return (entries as any[]).includes(module)
        }
      } else {
        return false
      }

      injected.isEntry = isEntry
      return isEntry(module)
    }
  }

  compiler.hooks.thisCompilation.tap('complugin:webpack', compilation => {
    thisCompilationPromise.resolve(compilation)

    compilation.hooks.childCompiler.tap('complugin:webpack', childCompiler => {
      childCompiler.webpack = compiler.webpack
      ;(childCompiler as any).$compluginInjected = injected
    })
  })

  compiler.hooks.compilation.tap('complugin:webpack', compilation => {
    const assets = compilation.assets
    const getAsset = compilation.getAsset
      ? (name: string) => compilation.getAsset(name)?.source
      : (name: string) => assets[name]
    const addAsset = (compilation.updateAsset as any)
      ? (fileName: string, source: import('webpack').sources.Source) => {
          if (fileName in assets) {
            compilation.updateAsset(fileName, source)
          } else {
            compilation.emitAsset(fileName, source)
          }
        }
      : (fileName: string, source: import('webpack').sources.Source) => {
          assets[fileName] = source
        }

    compilation.hooks.chunkAsset.tap('complugin:webpack', (chunk, chunkFileName) => {
      chunkAssetNameMap.set(chunkFileName, chunk)
      if (!hasCopyModule) return
      const asset = getAsset(chunkFileName)
      if (asset) {
        let source = asset.source().toString()
        let hasChange = false
        let map: any
        try {
          map = asset.map({ columns: true })
        } catch {}

        const magicString = new MagicString(source)

        for (const match of source.matchAll(placeholderGlobalMatchRegexp)) {
          const start = match.index ?? 0
          const end = start + match[0].length

          const groups = match.groups
          if (groups) {
            const { k, ref } = groups
            const fileName = copyModuleRefsMap.get(ref)

            if (k && fileName) {
              const replacement = stringifyModuleId(posixPath.relative(posixPath.dirname(chunkFileName), fileName))

              hasChange = true
              magicString.overwrite(start, end, replacement)
            }
          }
        }

        if (hasChange) {
          source = magicString.toString()
          map = map ? mergeSourceMap(map as any, magicString.generateMap({ hires: true })) : undefined
          const rawSource = toWebpackSource(source, compiler.webpack)
          if (map) {
            rawSource.map = () => map
            rawSource.sourceAndMap = sourceAndMap
          }

          addAsset(chunkFileName, rawSource)
        }
      }
    })

    compilation.hooks.finishModules.tap('complugin:webpack', modules => {
      for (const _module of modules) {
        const module: import('webpack').NormalModule = _module as any
        const resource = module.resource
        const addDependency = module.addDependency
        if (typeof resource === 'string' && typeof addDependency === 'function') {
          const watchFiles = watchFilesMap.get(resource)
          if (watchFiles) {
            watchFilesMap.delete(resource)
            for (const file of watchFiles) addDependency.call(module, new ModuleDependency(file))
          }
        }
      }
    })

    const getBaseAndFormat = (chunk: import('webpack').Chunk) => {
      const { publicPath: globalPublicPath, module } = compilation.outputOptions
      const entryOptions = chunk.getEntryOptions?.()
      const publicPath =
        entryOptions && entryOptions.publicPath !== undefined ? entryOptions.publicPath : globalPublicPath

      const libraryType =
        (entryOptions?.library ?? compilation.outputOptions.library)?.type ??
        (entryOptions as any)?.libraryTarget ??
        (compilation.outputOptions as any).libraryTarget

      const base = publicPath === 'auto' || !publicPath || typeof publicPath !== 'string' ? undefined : publicPath
      let format: InternalModuleFormat = 'iife'

      if (libraryType) {
        if (libraryType === 'module') {
          format = 'es'
        } else if (libraryType.includes('commonjs') || libraryType.includes('cjs')) {
          format = 'cjs'
        } else if (libraryType.includes('system')) {
          format = 'system'
        } else if (libraryType.includes('amd')) {
          format = 'amd'
        } else if (libraryType.includes('umd')) {
          format = 'umd'
        }
      } else {
        if (module) {
          format = 'es'
        }
      }

      return [base, format] as const
    }

    // webpack5
    if (RuntimeModule) {
      class CompluginDirnameRuntimeModule extends RuntimeModule {
        constructor(public c_base: string, public c_format: InternalModuleFormat) {
          super('compluginPublicPathShim', RuntimeModule.STAGE_BASIC)
        }

        generate(): string {
          const { c_base, c_format, chunk } = this
          if (c_base) {
            let base = compilation.getPath(c_base, { hash: compilation.hash || 'XXXX' })

            if (!base.endsWith('/')) {
              base += '/'
            }

            return `__webpack_require__.__pub = ${JSON.stringify(base)};`
          }

          let _rel = posixPath.relative(posixPath.dirname(chunk.name), '')
          _rel = _rel ? _rel + '/' : ''
          return (
            `__webpack_require__.__pub = ${getCompluginDirnameExp(c_format)}` +
            (_rel ? ` + ${JSON.stringify(_rel + '/')}` : '') +
            ';'
          )
        }
      }

      compilation.hooks.runtimeRequirementInTree
        .for('__webpack_require__.__pub')
        .tap('complugin:webpack', (chunk, set) => {
          const [base, format] = getBaseAndFormat(chunk)

          compilation.addRuntimeModule(chunk, new CompluginDirnameRuntimeModule(base!, format))
        })
    } else {
      compilation.mainTemplate.hooks.requireExtensions.tap('complugin:webpack', (code, chunk, hash) => {
        const [c_base, c_format] = getBaseAndFormat(chunk)

        code += '\n\n// complugin-shims'

        if (c_base) {
          code += '\n' + `__webpack_require__.__pub = __webpack_require__.p + '/';`
        } else {
          let _rel = posixPath.relative(posixPath.dirname(chunk.name), '')
          code += '\n' + `__webpack_require__.__pub = ${getCompluginDirnameExp(c_format)}`
          if (_rel) {
            code += JSON.stringify(_rel + '/')
          }
          code += ';'
        }

        return code
      })
    }
  })

  compiler.hooks.normalModuleFactory.tap('complugin:webpack', normalModuleFactory => {
    const resolveIdHooks = [...preResolveIds, ...resolveIds, ...postResolveIds]

    resolveIdHooks.push([
      (_, importee, importer) => {
        if (path.isAbsolute(importee)) {
          return nodeResolve(importee, importer)
        }
      }
    ])

    const handler = (parser: import('webpack').javascript.JavascriptParser) => {
      parser.hooks.expression.for(WEBPACK_PUBLIC_PATH).tap(
        'complugin:webpack',
        RuntimeModule
          ? expr => {
              const dep = new ConstDependency('__webpack_require__.__pub', expr.range!, ['__webpack_require__.__pub'])
              dep.loc = expr.loc!
              parser.state.module.addPresentationalDependency(dep)

              return true
            }
          : expr => {
              const dep = new ConstDependency('__webpack_require__.__pub', expr.range!, false as any)
              dep.loc = expr.loc!
              parser.state.current.addDependency(dep)

              return true
            }
      )
    }

    normalModuleFactory.hooks.parser.for('javascript/auto').tap('complugin:webpack', handler)
    normalModuleFactory.hooks.parser.for('javascript/dynamic').tap('complugin:webpack', handler)
    normalModuleFactory.hooks.parser.for('javascript/esm').tap('complugin:webpack', handler)

    const compluginResolvePromise: Parameters<
      typeof normalModuleFactory.hooks.resolve.tapPromise
    >[1] = async resolveData => {
      const importee = resolveData.request

      if (placeholderRegexp.test(importee)) {
        return new ExternalModule(importee, getExternalModuleType(await thisCompilationPromise), importee)
      }

      const importer = injected.virtualResourceToRequest(resolveData.contextInfo?.issuer)
      const compilation = await thisCompilationPromise

      for (const [resolveId, load] of resolveIdHooks) {
        const resolvedResult = await resolveId(compilation, importee, importer)
        if (resolvedResult == null) {
          continue
        }

        let id: string
        let external = false

        if (isString(resolvedResult)) {
          id = String(resolvedResult)
        } else if (isEmitAssetArgs(resolvedResult)) {
          const ref = uniqueCopyModuleRef()
          const fileName = getAssetFileName(resolvedResult, compilation.outputOptions.assetModuleFilename)
          setWebpackAsset(fileName, resolvedResult.source ?? '', compilation)
          copyModuleRefsMap.set(ref, fileName)
          hasCopyModule = true

          const idPlaceholder = placeholderPrefix + ref + placeholderSuffix
          resolveData.request = idPlaceholder
          return new ExternalModule(idPlaceholder, getExternalModuleType(compilation), idPlaceholder)
        } else {
          id = String(resolvedResult.id ?? '')
          external = Boolean(resolvedResult.external)
        }

        if (!id) {
          continue
        }

        const importerKey = importer || ''
        let importerRecord = resolveRecordsMap.get(importerKey)
        if (!importerRecord) {
          resolveRecordsMap.set(importerKey, (importerRecord = new Map()))
        }

        importerRecord.set(importee, id)

        if (external) {
          externalIds.add(id)
          resolveData.request = id
          return new ExternalModule(id, getExternalModuleType(compilation), id)
        } else {
          externalIds.delete(id)

          const resource = (resolveData.request = injected.requestToVirtualResource(id))
          if (!loadMap.has(resource)) {
            let code: string | undefined
            if (load) {
              const result = await load(id, resource, compilation)

              let map: any
              let ast: AcornNodePri | undefined

              if (result == null) {
              } else if (isString(result)) {
                code = String(result)
              } else if ((result as any).code != null) {
                let copy, name, fileName
                ;({ code, map, ast, copy, name, fileName } = result as any)
                code = String(code)

                if (copy) {
                  const ref = uniqueCopyModuleRef()
                  const assetFileName = getAssetFileName(
                    { source: code, name: name || path.basename(id), fileName },
                    compilation.outputOptions.assetModuleFilename
                  )
                  setWebpackAsset(assetFileName, map ? { code, map } : code, compilation)
                  copyModuleRefsMap.set(ref, assetFileName)
                  hasCopyModule = true

                  const idPlaceholder = placeholderPrefix + ref + placeholderSuffix
                  resolveData.request = idPlaceholder

                  loadMap.set(resource, [code, map, ast])

                  return new ExternalModule(idPlaceholder, getExternalModuleType(compilation), idPlaceholder)
                }

                if (isAst(ast)) {
                  ;(ast as AcornNodePri)[AcornCodeKey] = code
                } else {
                  ast = undefined
                }
              }

              if (code != null) {
                loadMap.set(resource, [code, map, ast])
              }
            } else if (resource === id) {
              try {
                code = await readFile(resource, 'utf-8')
              } catch (error) {
                code = ''
              }
            }

            vfs?.writeModule(resource, code ?? '')
          }
        }
        return
      }
    }

    const resolveHook = normalModuleFactory.hooks.resolve

    if (resolveHook) {
      // webpack5
      resolveHook.tapPromise('complugin:webpack', compluginResolvePromise)
    } else {
      const oldFactory = (normalModuleFactory.hooks as any).factory.call(null)

      ;(normalModuleFactory.hooks as any).factory.tap(
        'complugin:webpack',
        () => async (result: any, callback: Function) => {
          let module
          try {
            module = await compluginResolvePromise(result)
          } catch (error) {
            return callback(error)
          }

          if (module === false) {
            return callback()
          }

          if (module instanceof WebpackModule) {
            return callback(null, module)
          }

          return oldFactory(result, callback)
        }
      )
    }
  })

  compiler.hooks.make.tapPromise('complugin:webpack', async compilation => {
    await Promise.all(
      [...preBuildStarts, ...buildStarts, ...postBuildStarts].map(buildStart => buildStart(compilation))
    )
  })

  compiler.hooks.afterCompile.tapPromise('complugin:webpack', async compilation => {
    const { webpack, inputFileSystem: fs } = compiler

    await Promise.all([...preBuildEnds, ...buildEnds, ...postBuildEnds].map(buildEnd => buildEnd(compilation)))

    const devtool =
      compilation.options.devtool ?? compilation.options.mode === 'development' ? 'inline-source-map' : false
    const outdir = compilation.outputOptions.path ?? compilation.compiler.context

    const assets = compilation.assets
    const addAsset = (compilation.updateAsset as any)
      ? (fileName: string, source: import('webpack').sources.Source) => {
          if (fileName in assets) {
            compilation.updateAsset(fileName, source)
          } else {
            compilation.emitAsset(fileName, source)
          }
        }
      : (fileName: string, source: import('webpack').sources.Source) => {
          assets[fileName] = source
        }

    let sourceMap: undefined | true | 'inline' | 'hidden'
    let sourceContent = !!devtool && !devtool.includes('nosources')

    if (devtool) {
      if (devtool.includes('inline') || devtool.includes('eval')) {
        sourceMap = 'inline'
      } else if (devtool.includes('hidden')) {
        sourceMap = 'hidden'
      } else {
        sourceMap = true
      }
    }

    await Promise.all([
      ...iterableMap(injected.staticFiles, ([id, fileName]) => {
        return new Promise<void>((resolve, reject) => {
          fs.readFile(id, (err, source) => {
            if (err != null) {
              return reject(err)
            }

            addAsset(fileName, toWebpackSource(source ?? '', webpack))
            resolve()
          })
        })
      }),
      ...iterableMap(injected.emittedAssets, async ([fileName, source]) => {
        source = await source
        if (isString(source) || source instanceof Uint8Array) {
          addAsset(fileName, toWebpackSource(source, webpack))
        } else if (source) {
          let { code, map } = source

          map = map && isString(map) ? JSON.parse(map) : map
          if (map) {
            normalizeSourceMap(map, path.join(outdir, fileName), sourceContent)
            code = isString(code) ? code : toBuffer(code).toString()

            switch (sourceMap) {
              case true:
                code = addSourceComment(
                  code,
                  generateMapFileComment(path.basename(fileName) + '.map', { multiline: true })
                )
              case 'hidden':
                addAsset(fileName + '.map', toWebpackSource(JSON.stringify(map), webpack))
                break
              case 'inline':
                code = addSourceComment(code, fromObject(map).toComment({ multiline: true }))
                break
            }
          }

          const mapSource = toWebpackSource(code, webpack)
          mapSource.map = () => map as any
          mapSource.sourceAndMap = sourceAndMap

          addAsset(fileName, mapSource)
        }
      })
    ])

    injected.emittedAssets.clear()
    injected.staticFiles.clear()
  })

  compiler.hooks.emit.tapPromise('complugin:webpack', async compilation => {
    const assets = compilation.assets

    const generateBundleHooks = [...preGenerateBundles, ...generateBundles, ...postGenerateBundles]
    if (generateBundleHooks.length > 0) {
      type Asset = typeof assets[string]

      const cache = new WeakMap<any, OutputFile & { _raw: Asset }>()
      const newAsset = (oldAsset: Asset, assetProxy: OutputFile & { _raw: Asset }, source: string | Buffer) => {
        cache.delete(oldAsset)
        const newAsset = toWebpackSource(source)
        newAsset.map = oldAsset.map.bind(oldAsset)
        newAsset.sourceAndMap = sourceAndMap

        cache.set(newAsset, assetProxy)
        return newAsset
      }
      const getAsset = compilation.getAsset
        ? (name: string) => compilation.getAsset(name)?.source
        : (name: string) => assets[name]
      const addAsset = (compilation.updateAsset as any)
        ? (fileName: string, source: import('webpack').sources.Source) => {
            if (fileName in assets) {
              compilation.updateAsset(fileName, source)
            } else {
              compilation.emitAsset(fileName, source)
            }
          }
        : (fileName: string, source: import('webpack').sources.Source) => {
            assets[fileName] = source
          }

      const removeAsset = compilation.deleteAsset?.bind(compilation) ?? (f => void delete assets[f])

      const bundleProxy = new Proxy(assets, {
        get: (target, name: string, receiver) => {
          if (typeof name !== 'string') return
          let asset = getAsset(name)
          if (asset) {
            if (cache.has(asset)) {
              return cache.get(asset)
            }

            const chunk = chunkAssetNameMap.get(name)

            const proxyAsset: OutputFile & { _raw: Asset } = {
              get _raw() {
                return asset!
              },
              set _raw(source) {
                cache.delete(asset)
                asset = source
                cache.set(asset, proxyAsset)
              },
              get fileName() {
                return name
              },
              set fileName(value) {
                if (typeof value !== 'string') return
                const oldFileName = name
                name = value
                removeAsset(oldFileName)
                addAsset(value, asset!)
              },
              get text() {
                return asset!.source().toString()
              },
              set text(value) {
                addAsset(name, (asset = newAsset(asset!, proxyAsset, value)))
              },
              get contents() {
                return asset!.buffer()
              },
              set contents(value) {
                addAsset(name, (asset = newAsset(asset!, proxyAsset, value)))
              }
            }

            if (chunk) {
              Object.defineProperty(proxyAsset, 'meta', {
                configurable: true,
                enumerable: true,
                get: once(() => {
                  const {entryModule} = chunk
                  const resource = (entryModule as any)?.resource
                  return {
                    isEntry: Boolean(entryModule),
                    entryPoint: resource,
                    // TODO imports,
                    // TODO exports
                  }
                })
              })
            }

            cache.set(proxyAsset, proxyAsset)
            return proxyAsset
          }
        },
        set: (target, name: string, value) => {
          if (typeof name !== 'string') return false
          if (cache.has(value)) {
            value = cache.get(value)
            if (value) {
              value.fileName = name
            } else {
              removeAsset(name)
              return true
            }
          } else if (isString(value) || isBuffer(value)) {
            value = toWebpackSource(value)
          } else if (isObject(value)) {
            const { contents, text, source, map } = value as any
            let asset: Asset
            if (typeof source === 'function') {
              asset = toWebpackSource(source())
            } else {
              const source = contents ?? text
              if (typeof source === 'string' || isBuffer(source)) {
                asset = toWebpackSource(source)
              } else {
                return false
              }
            }

            asset.map = typeof map === 'function' ? map.bind(value) : () => map
            asset.sourceAndMap = sourceAndMap

            value = asset
          } else {
            return false
          }

          addAsset(name, value)
          return true
        }
      })

      for (const generateBundle of generateBundleHooks) {
        await generateBundle(bundleProxy as any, assets)
      }
    }

    copyModuleRefsMap.clear()
    resolveRecordsMap.clear()
    chunkAssetNameMap.clear()
    loadMap.clear()
  })

  compiler.resolverFactory.hooks.resolver.for('normal').tap('complugin:webpack', resolver => {
    resolverPromise.resolve(resolver)
  })

  return injected
}
